Rule 与 LLM 的边界
一个系统里同时用规则引擎和 LLM 时,边界画在哪里决定了它是否可维护。判据是问题的性质,不是技术的新旧。
| 问题性质 | 承担方 | 理由 |
|---|---|---|
| 确定性、可枚举、有明确判据 | Rule | 可解释、可审计、成本固定、结果可复现 |
| 开放语义、需要理解意图 | LLM | 规则无法穷举,写了也维护不住 |
| 高风险且无人可兜底 | Human | 错了代价不可逆 |
对应的失效信号:规则承接开放语义,会退化成不断加白名单的补丁堆;LLM 承接确定性判断,会引入不可复现的结果和无法审计的决策链。
确定性约束优于概率性嘱托
这一点和 Harness Engineering 的控制层是同一件事。让模型「遵守规范」是概率性合规,接一个违反规范就阻断 PR 的 Linter 是结构性约束。
在规则引擎里同理:靠提示词让模型「优先校验字段完整性」,和把字段校验写死在决策树的第一个分支,可靠性不在一个量级。前者的通过率是统计量,后者是确定事件。
边界画在哪里由「问题的性质」决定,不是技术的新旧。
问题性质 承担方 失效信号
──────────────────────────────────────────────────────────────
确定性、可枚举、有明确判据 Rule ——
└─ 可解释、可审计、成本固定、结果可复现
开放语义、需要理解意图 LLM ——
└─ 规则无法穷举,写了也维护不住
高风险且无人可兜底 Human ——
└─ 错了代价不可逆
两类画错的后果
规则承接开放语义 ──▶ 退化成不断加白名单的补丁堆
LLM 承接确定性判断 ──▶ 引入不可复现的结果和无法审计的决策链
一句话:让模型「遵守规范」是概率性合规,
接一个违反规范就阻断 PR 的 Linter 是结构性约束。
在规则引擎里同理:靠提示词让模型「优先校验字段完整性」,
和把字段校验写死在决策树的第一个分支,可靠性不在一个量级 ——
前者的通过率是统计量,后者是确定事件。语义泛化:关键词白名单的边界
具体场景:系统目前只认 Button Label 为「继续」「下一步」的主流程按钮,之后出现「继续填写」「确认并继续」「去下一步」「Continue」「Proceed」怎么办。
关键词/白名单方案:维护 ["继续", "下一步", "确认", ...]。稳定、可解释、成本低。代价是泛化能力差,每次文案更新都要加规则,而且一旦有新文案没被覆盖,系统静默走兜底。
语义模型方案:把 Button Label、Button Role、Button Region、Page Context 一起交给模型判断「是否属于主流程继续行为」。
需要注意的写法错误是 label contains "继续" → 点击。这类判断把语义问题降级成了字符串匹配,等于两边都没做好。
实际可用的分法是分层:规则处理确定性部分(按钮是否 enabled、是否在 Help 区域、是否有多个候选),语义判断处理标签含义,两者结论冲突时走 置信度分层与 Human-in-the-loop 里的降级路径。
规则引擎怎么执行:三阶段与冲突集
要谈规则优先级,先得知道规则引擎内部是什么模型。成熟实现(Drools / BizTalk / Grule 等)都是匹配 → 冲突解决 → 动作的三阶段循环:
| 阶段 | 做什么 |
|---|---|
| 匹配 | 用规则条件里的谓词去匹配工作内存中的事实。为效率,模式匹配在全部规则上做,且跨规则共享的条件只匹配一次 |
| 冲突解决 | 匹配到的候选规则进入冲突集(Conflict Set),按预定策略决定下一个执行哪条 |
| 动作 | 执行被选中规则的动作。动作可以断言新事实,于是循环继续 —— 这叫正向推理(forward chaining) |
冲突集里只有一条规则时不存在冲突,直接执行;有多条才需要冲突解决策略。没有任何规则匹配时,循环停止。
一条容易违反的性质:算法永不抢占当前正在执行的规则——当前被触发规则的所有动作必须全部执行完,才重新进入匹配阶段。也就是说动作中途不会被打断,这也意味着单条规则的动作应该是幂等且可重入的。
规则引擎内部是「匹配 → 冲突解决 → 动作」的三阶段循环。
┌─ ① 匹配 ─────────────────────────────────────────────┐
│ 用规则条件里的谓词去匹配工作内存中的事实 │
│ 为效率:模式匹配在全部规则上做, │
│ 且跨规则共享的条件只匹配一次 │
└────────────────────────┬─────────────────────────────┘
▼
┌─ ② 冲突解决 ─────────────────────────────────────────┐
│ 匹配到的候选规则进入冲突集(Conflict Set) │
│ 按预定策略决定下一个执行哪条 │
│ └─ 只有一条规则时不存在冲突,直接执行 │
│ 有多条才需要冲突解决策略 │
└────────────────────────┬─────────────────────────────┘
▼
┌─ ③ 动作 ─────────────────────────────────────────────┐
│ 执行被选中规则的动作 │
│ └─ 动作可以断言新事实,于是循环继续 │
│ 这叫正向推理(forward chaining) │
└────────────────────────┬─────────────────────────────┘
│
└──▶ 回到 ①(没有任何规则匹配时循环停止)
一条容易违反的性质:算法永不抢占当前正在执行的规则
当前被触发规则的所有动作必须全部执行完,才重新进入匹配阶段。
动作中途不会被打断 —— 这也意味着单条规则的动作应该是幂等且可重入的。规则冲突的四种解决策略
两条以上规则同时命中、且结论不同时,靠这四种之一决定谁赢:
| 策略 | 规则 | 适用 |
|---|---|---|
| 特异性(Specificity) | 条件更具体的规则优先。具体性可粗略定义为「前置条件数量最多」 | 捕获例外与特殊情况——它会在触发更通用的(默认)规则之前先命中 |
| 优先级(Salience / Priority) | 给规则一个数值优先级,高的先跑 | 需要人工显式控制时。Drools 的默认是 Salience + LIFO |
| 拒绝优先(Deny Takes Precedence) | 只要有任何一条匹配规则说拒绝,动作就被拒绝,不管其他规则允不允许 | 安全关键系统里最常见。理由很硬:保证单条拒绝规则不会被别处新增的允许规则覆盖掉 |
| 规则顺序 | 单条策略内:先匹配的赢还是后匹配的赢 | 要显式定义,别靠默认 |
「拒绝优先」这条值得单独记:它的价值不在严格,在可增量演进——新增一条允许规则不会意外打开一个原本关闭的口子。规则集在长期迭代里最怕的就是这种「改 A 出错在 B」。
部署前跑冲突分析
自动工具能识别「匹配的动作集重叠、但效果不同」的规则对。 提前解决歧义,而不是在事故里发现它。
冲突解决是设计决策,不是事后补丁。 选定策略、写进文档、一致地执行。
一条来自 Drools 文档的实践原则
一般原则是:不要指望规则按任何特定顺序触发,写规则时不该操心「流程」。当确实需要流程时,才用 agenda groups / rule flow groups / activation groups 这类机制显式表达。
后半句是关键——「需要流程」可以做,只是不能靠命中顺序碰运气。
两个实现层面的坑
一、规则集必须有循环次数上限。 Grule 的做法是:重复评估与执行的次数超过实例化时指定的上限,引擎直接终止并返回错误。没有这个上限,正向推理可以无限循环下去——这和 Agent Loop 的 max_steps 是同一类护栏。
二、不能假设规则执行顺序与添加顺序一致。 Grule 里优先级相同的多条规则,引擎选「找到的第一条」,而底层 Go map 不保证输入顺序。所以「我按顺序加的,应该按顺序跑」这个假设在实现层就不成立。未指定优先级的规则默认 salience 为 0,这也是为什么显式优先级很重要——默认值相同意味着顺序未定义。
决策树的分支顺序
回到具体场景——决策树的分支顺序本身是设计决策,不能靠命中顺序碰运气。正确的顺序是:
决策树的分支顺序本身是设计决策,不能靠命中顺序碰运气
① 字段完整性校验 ← 适用条件最宽:任何页面都要先校验字段
│
▼
② 业务字段一致性校验
│
▼
③ 页面 / Button 判断
│
▼
④ 执行动作
为什么字段校验必须在最前(用「特异性」解释)
字段校验的适用条件比按钮判断更宽 —— 把它放在前面意味着
无论后面命中什么分支,它都已经生效过。
└─ 错误写法:看到按钮文案是「继续」就直接点击。
字段缺失的情况下,按钮存在不代表可以继续执行 ——
正确输出应该是「缺少必要字段,需要补充字段」。
这里考的是规则优先级,而不是规则本身写没写对。
同类的还有 last_result
它的取值(例如 timeout)也应该在较靠前的位置判断,
因为它会推翻后面所有分支的结论 ——
超时之后不能直接继续,要先重新查询状态。错误写法是看到按钮文案是「继续」就直接点击。字段缺失的情况下,按钮存在不代表可以继续执行——正确输出应该是「缺少必要字段,需要补充字段」。这里考的是规则优先级,而不是规则本身写没写对。
这条顺序的逻辑可以用上面的「特异性」解释:字段校验的适用条件比按钮判断更宽(任何页面都要先校验字段),把它放在前面意味着无论后面命中什么分支,它都已经生效过。
同类的还有 last_result:它的取值(例如 timeout)应该在决策树较靠前的位置判断,因为它会推翻后面所有分支的结论——超时之后不能直接继续,要先重新查询状态。
三种失败方向
未知输入的处理方式是这套设计的核心问题。先分清三种失败方向:
| 方向 | 失败时做什么 | 什么时候用 |
|---|---|---|
| Fail Safe | 停在安全侧 —— 拒绝执行、暂停、交给人 | 动作有副作用且不可逆(本系统就是这一类) |
| Fail Open | 放行 —— 让流程继续 | 只在放行的代价确定小于阻断的代价时,例如某些可用性优先的读路径 |
| Fail Fast | 立刻报错并停止 —— 让问题尽早暴露 | 开发期、配置加载期;错误继续跑只会掩盖问题 |
本系统的合理答案是 Fail Safe:
本系统的合理答案是 Fail Safe
Known Case
│
▼
┌─────────┐ ┌──────────────┐
│ Rules │ ─────▶ │ 执行 │
└─────────┘ └──────────────┘
Unknown Case
│
▼
┌─────────┐ ┌──────────────────────────┐
│ Stop │ ─────▶ │ Ask User / Human Review │
└─────────┘ └──────────────────────────┘
└─ 规则没命中时走兜底、暂停自动操作、询问用户,代价是一次人工介入;
猜错的代价是「执行了一个不该执行的动作」——
这两个代价不对称,所以选择是明确的。
三种失败方向的判据可以写成一句话
把「失败时代价更大的是误做还是不做」想清楚,方向就定了
├─ 误做的代价不可逆 ──▶ Fail Safe(停在安全侧:拒绝执行、暂停、交给人)
├─ 不做的代价不可逆 ──▶ Fail Open(放行,如错过告警)
└─ 开发期 / 配置加载期 ──▶ Fail Fast(立刻报错并停止,继续跑只会掩盖问题)不是随便选一个看起来最像的分支执行。 规则没命中时走兜底、暂停自动操作、询问用户,代价是一次人工介入;猜错的代价是执行了一个不该执行的动作——这两个代价不对称,所以选择是明确的。
判据可以写成一句话:把「失败时代价更大的是误做还是不做」想清楚,方向就定了。误做的代价不可逆 → Fail Safe;不做的代价不可逆(如错过告警)→ Fail Open。
相关
- Harness Engineering —— 确定性约束在 Agent 环境中的位置
- 置信度分层与 Human-in-the-loop —— 边界画不清时的降级路径
- LLM Evaluation 与反馈闭环 —— 规则如何从线上 Bad Case 反哺
- Agent Loop —— 循环上限这道护栏的另一种形态
参考
- https://www.authensor.com/learn/policy-conflict-resolution-in-ai-systems
- https://handwiki.org/wiki/Conflict_resolution_strategy
- https://docs.drools.org/6.5.0.Final/drools-docs/html/ch07.html
- https://github.com/hyperjumptech/grule-rule-engine/blob/master/docs/en/RuleEngine_en.md
- https://learn.microsoft.com/zh-cn/biztalk/core/condition-evaluation-and-action-execution
YJ